iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Software Development

Kotlin Ktor 實戰 101系列 第 27 篇

Kotlin Ktor 實戰 101 Day 27 JWT 認證

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

這篇整個認證機制從「拿字串比對設定檔裡的名單」換成「驗一段 HS256 簽章」,jwt provider 的 3 個 hook 各管什麼、secret 跟 issuer 從哪裡來、/login 跟 /refresh 該擺在哪一層

換法選對的話,authenticate(TODO_AUTH) 那一行不用動,meRoute() 也不用動,但這不是框架給的保證,條件在我們自己怎麼寫那個驗證函式,同一個機制換個寫法,下游全部要改

這篇要完成什麼

  • 一行 ktorLibs.server.auth.jwt 會帶進一整串相依,包含這篇完全用不到的 jwks-rsa
  • JWT 就是 3 段字串,前 2 段誰都解得開
  • jwt provider 的 verifier、validate 跟 challenge 各管什麼
  • secret、issuer、audience 跟 2 個 TTL 從 application.yaml 進來,憑證不給版控預設值,測試憑證放在 test source set
  • claim 命名踩到的一次撞名,typ 是 JOSE header 的參數名
  • validate 回自己的型別而不是 JWTPrincipal,meRoute() 不用跟著改,換掉整個認證機制只動到 3 個既有測試檔案,以及那句「middleware 那一層不用知道差別」成立的條件
  • /login 跟 /refresh 為什麼在 authenticate 外面,失敗為什麼是 400 不是 401,兩份 RFC 在這裡是打架的
  • day 26 留下的 2 筆 RFC 債,challenge 這個鉤子怎麼還掉
  • 被拒的 token 對外一模一樣,對內在 log 裡分得出來是誰擋的
  • HMAC secret 的長度 java-jwt 完全不管,RFC 7518 那條 MUST 說的是什麼
  • 過期的 token 怎麼測,不用等、不用 sleep

一行相依帶進來多少東西

先加相依,build.gradle.kts 的 dependencies 區塊,day 26 那一行下面再加一行

+    implementation(ktorLibs.server.auth.jwt)

ktorLibs 是 day 02 就在用的 Ktor version catalog,所以一樣不用寫版號

這一行帶進來的不只 java-jwt 自己,把 runtimeClasspath 列出來看,多出來的那些裡面有 2 個可以注意一下

一個是 Jackson,這個專案的 JSON 一路都是 kotlinx.serialization,ContentNegotiation 那邊也是,但 java-jwt 是 Auth0 的 Java 函式庫,它自己要一套 JSON,同一個 process 裡從此有兩套 JSON 實作

另一個是 jwks-rsa 跟它拖著的 Guava,JWKS 是「從遠端拉公鑰來驗 RS256」那條路,這篇用的是 HS256 對稱金鑰,完全不會碰到它,但它還是躺在 runtime classpath 裡

JWT 就是三段字串

JWT 是用點分成 3 段的一串文字,前 2 段是 base64url 編碼的 JSON,第 3 段是簽章

下面 /login 那節會看到完整的回應

第 1 段是 header,逐字是

{"alg":"HS256","typ":"JWT"}

第 2 段是 payload,逐字是

{"iss":"todo-api","aud":"todo-api-client","sub":"alice","token_use":"access","iat":1788492289,"exp":1788493189}

1788493189 - 1788492289 = 900,正好是設定裡的 accessTtlSeconds

這裡要講清楚一件常被誤解的事,base64url 只是編碼,任何拿到這串文字的人都解得開上面那 2 段,簽章擋的不是「看見」,是「改動」,第 3 段是拿 secret 對前 2 段算出來的 HMAC,改掉 payload 裡任何一個字元,簽章就對不上

所以 JWT 的 payload 不能放密碼、不能放個資,能放的是「這個人是誰、這張 ticket 能用到什麼時候」,上面那個 payload 裡就只有簽發者、對象、使用者名稱、用途跟 2 個時間戳

sub、iss、aud、iat、exp 這幾個名字不是我們取的,是 RFC 7519 4.1 那份註冊 claim 清單裡的,token_use 才是我們自己加的

jwt provider 的三個 hook

jwt 的設定物件在 JWTAuth.kt 裡,下面是節錄,KDoc 跟 internal 的欄位拿掉之後,這篇會用到的是 4 個

public class Config internal constructor(
    name: String?,
    description: String?
) : AuthenticationProvider.Config(name, description) {

    public var realm: String = "Ktor Server"

    public fun verifier(verifier: JWTVerifier) { ... }

    public fun validate(validate: suspend ApplicationCall.(JWTCredential) -> Any?) { ... }

    public fun challenge(block: JWTAuthChallengeFunction) { ... }
}

realm 跟 day 26 一樣,是要放進 401 那個 header 的字串,差別在 bearer 那邊的型別是 String? 預設 null,這邊是 String 預設 "Ktor Server",不寫的話 header 上就會出現一個不是自己取的名字,剩下 3 個是這篇的主角,而且順序就是請求會經過的順序

verifier 收一個 com.auth0.jwt.interfaces.JWTVerifier,型別直接是 java-jwt 的 (它還有幾個多載,吃 JwkProvider 的那幾個是 RS256 加 JWKS 那條路,這篇用不到),這一層做的是機械式的檢查,簽章對不對、iss 對不對、aud 對不對、過期沒有,它不認識我們的 domain,只認識 JWT 規格裡的東西

validate 收的是 suspend ApplicationCall.(JWTCredential) -> Any?,跟 day 26 的 bearer.authenticate 是同一個形狀,ApplicationCall 是 receiver 不是參數,所以在裡面直接寫 call 那一票東西不用再多接一層,回傳型別是 Any?,KDoc 那行 @return a principal (usually an instance of [JWTPrincipal]) or null 說得很清楚,它的 KDoc 還多講一句 This callback is required,沒設定的話 provider 初始化就會丟 IllegalArgumentException,回傳型別跟 day 26 那個一模一樣,這件事後面有大用

JWTCredential 跟 JWTPrincipal 這 2 個型別看起來一進一出很對稱,但原始碼裡它們是同一個父類別的 2 行宣告

public class JWTCredential(payload: Payload) : JWTPayloadHolder(payload)

public class JWTPrincipal(payload: Payload) : JWTPayloadHolder(payload)

2 個都是 JWTPayloadHolder 加一個建構子,本體沒有多任何東西,Ktor 文件的範例是在 validate 裡回 JWTPrincipal(credential.payload),效果就是把剛剛拿到的 payload 原封不動換一個型別再放回去

challenge 是 day 26 沒有的,它收的 JWTAuthChallengeFunction 是一個 typealias

public typealias JWTAuthChallengeFunction =
    suspend JWTChallengeContext.(defaultScheme: String, realm: String) -> Unit

receiver 是 JWTChallengeContext,那個類別的本體就只有一個 val call: ApplicationCall,2 個參數是 scheme 跟 realm,合起來的意思是「這個請求要被拒絕了,401 你自己回」,day 26 的 bearer provider 沒有這個鉤子,所以那篇只能接受 Ktor 預設的挑戰,也因此欠下 2 筆 RFC 的債

調整設定

src/main/resources/application.yaml 的 todo.auth 節點改成這樣

todo:
  auth:
    realm: '$AUTH_REALM:todo-api'
    jwt:
      secret: '$JWT_SECRET'
      issuer: '$JWT_ISSUER:todo-api'
      audience: '$JWT_AUDIENCE:todo-api-client'
      accessTtlSeconds: '$JWT_ACCESS_TTL:900'
      refreshTtlSeconds: '$JWT_REFRESH_TTL:604800'
    users:
      - name: '$ALICE_NAME:alice'
        password: '$ALICE_PASSWORD'
      - name: '$BOB_NAME:bob'
        password: '$BOB_PASSWORD'

$XXX:預設值 是 day 17 那套環境變數覆寫語法,正式環境用環境變數蓋掉

3 個欄位刻意沒有預設值,secret 跟 2 個 password,這 3 個都是憑證,secret 拿到就能簽出任何身分的 ticket,password 拿到就能換到 ticket,有效憑證不該當成一般設定提交進版控

這裡沿用 day 26 的 fail-fast 做法,沒提供就啟動失敗,name、realm、issuer、audience 跟 2 個 TTL 保留預設值,那些不是憑證

沒給 JWT_SECRET 就起 server,拿到的是這個

Exception in thread "main" io.ktor.server.config.ApplicationConfigurationException: Required environment variable "JWT_SECRET" not found and no default value is present

啟動失敗比啟動成功好,少了一個環境變數的部署會在第 1 秒就掛掉,而不是帶著一把版控裡人人看得到的 secret 對外服務

使用者只換了一個欄位名,day 26 的 token 是「拿這串來就放你進去」,現在的 password 是「拿這串來換一張 ticket」,名單本身還在 YAML 裡,這是這篇沒有解決的問題

2 個 TTL 的差距是刻意的,access token 900 秒、refresh token 604800 秒也就是 7 天,access token 到處跑、被攔截的機會多,所以活得短,refresh token 只在 /refresh 這一條路上用,所以可以活久一點

src/main/kotlin/com/cashwu/todo/TodoConfig.kt 對應多一個 data class,AuthUser 的欄位跟著換

@Serializable
data class AuthConfig(
    val realm: String,
    val jwt: JwtConfig,
    val users: List<AuthUser>,
)

@Serializable
data class JwtConfig(
    val secret: String,
    val issuer: String,
    val audience: String,
    val accessTtlSeconds: Long,
    val refreshTtlSeconds: Long,
)

@Serializable
data class AuthUser(
    val name: String,
    val password: String,
)

YAML 那邊 TTL 是字串 "$JWT_ACCESS_TTL:900",Kotlin 這邊宣告成 Long,day 17 那套 property("todo").getAs<TodoConfig>() 把它處理掉了

改到這裡整個專案編譯不過,AuthUser 的 token 換成了 password,day 26 那個 TokenAuthenticator 讀的是 AuthUser::token,TestApp.kt 那行 TEST_TOKEN 讀的也是,ConfigurationTest 的期望值那 2 行 AuthUser("alice", "token-alice") 比較陰險,2 個參數都是 String,編譯器不會抱怨,但第二格現在的意思是密碼

day 26 那次是先把測試補回綠燈再往下寫,這次辦不到,因為要換上去的東西還沒寫,順序是這樣

  1. 「測試憑證放在 test source set」把 tasks.test 的 3 個憑證換掉
  2. 「TokenIssuer 跟撞名的那個 claim」跟「validate 回自己的型別」把 TokenIssuer 寫進 Auth.kt,day 26 那個舊的 todoAuth 整段刪掉、換成新的
  3. 「/login 跟 /refresh」把 day 26 那個 TokenAuthenticator 整個刪掉、換成 PasswordAuthenticator,再改 Application.kt 的接線,到這裡 main 才重新編得過,才能起 server
  4. 「既有測試只動了 3 個檔案」改 ConfigurationTest、TestApp.kt 跟 AuthenticationTest,到這裡測試才會綠

第 3 步那個刪除很容易漏掉,PasswordAuthenticator 是拿來取代 TokenAuthenticator 的,不是多加一個類別,舊的留在 Auth.kt 裡就是 Unresolved reference 'token',起不了 server

測試憑證放在 test source set

正式啟動要環境變數,測試不能每次跑之前先 export 3 個東西,這 2 件事不衝突,因為測試有自己的地方可以放憑證

build.gradle.kts 的 tasks.test 已經有 day 23 跟 day 26 留下的 3 行,這篇把 2 個 token 換成 2 個 password,再加一行 JWT_SECRET

 tasks.test {
     useJUnitPlatform()
     environment("DB_PASSWORD", "")
-    environment("ALICE_TOKEN", "token-alice")
-    environment("BOB_TOKEN", "token-bob")
+    environment("JWT_SECRET", "test-secret-32-bytes-minimum-okay")
+    environment("ALICE_PASSWORD", "test-alice-secret")
+    environment("BOB_PASSWORD", "test-bob-secret")
     testLogging {

DB_PASSWORD 那行是 day 23 換 PostgreSQL 那次留下來的,一樣沒有預設值,不能跟著拿掉,做法就是 day 26 那次的做法,測試看到的是假憑證,application.yaml 一個字都不用為了測試放寬,正式啟動看到的還是「沒給就失敗」

跟著換的是 ConfigurationTest 那份整份比對的期望值,那個測試比對的是設定解析出來的結果,設定值換了它就要跟著換

TokenIssuer 跟撞名的那個 claim

簽 token 跟驗 token 用的是同一把 secret、同一組 issuer 跟 audience,所以把它們放在同一個類別裡,同時負責發跟驗,這一段放在 src/main/kotlin/com/cashwu/todo/Auth.kt,位置在 todoAuth 上面

class TokenIssuer(private val config: JwtConfig, private val clock: Clock) {
    private val algorithm: Algorithm = Algorithm.HMAC256(config.secret)

    fun accessToken(user: TodoUser): String =
        sign(user, ACCESS_TOKEN, config.accessTtlSeconds)

    fun refreshToken(user: TodoUser): String =
        sign(user, REFRESH_TOKEN, config.refreshTtlSeconds)

    fun tokensFor(user: TodoUser): TokenResponse =
        TokenResponse(accessToken(user), refreshToken(user), config.accessTtlSeconds)

    fun verifier(): JWTVerifier =
        JWT.require(algorithm)
            .withIssuer(config.issuer)
            .withAudience(config.audience)
            .withClaimPresence(TOKEN_USE_CLAIM)
            .build()

    private fun sign(user: TodoUser, type: String, ttlSeconds: Long): String {
        val now = clock.instant()
        return JWT.create()
            .withIssuer(config.issuer)
            .withAudience(config.audience)
            .withSubject(user.name)
            .withClaim(TOKEN_USE_CLAIM, type)
            .withIssuedAt(now)
            .withExpiresAt(now.plusSeconds(ttlSeconds))
            .sign(algorithm)
    }
}

tokensFor 回的 TokenResponse 是 /login 跟 /refresh 共用的回應形狀,跟另外 2 個請求的 data class 一起放在同一個檔案,形狀在下一節,這裡先當它已經在

同一個檔案上面多 3 個常數跟改原有的 1 個

const val TODO_AUTH = "todo-jwt"
const val TOKEN_USE_CLAIM = "token_use"
const val ACCESS_TOKEN = "access"
const val REFRESH_TOKEN = "refresh"

TODO_AUTH 從 day 26 的 "todo-bearer" 改成 "todo-jwt",只是名字換了,authenticate(TODO_AUTH) 那一行還是同一行

TOKEN_USE_CLAIM 這個名字是踩過一次才長成這樣的,access token 跟 refresh token 除了活多久之外沒有差別,所以要在 payload 裡放一個欄位標記它的用途,最早寫的是 typ,但上面解出來的 header 逐字是

{"alg":"HS256","typ":"JWT"}

typ 已經是 JOSE header 的參數名了,在 payload 裡再放一個 typ 是 2 個不同層的欄位同名,技術上 java-jwt 不會爆,但拿 jwt.io 這種工具去看的時候,2 個 typ 分別在上下 2 個框裡,講給別人聽也很難講,改成 token_use 之後就比較清楚

withClaimPresence(TOKEN_USE_CLAIM) 那一行是把「這個 claim 一定要有」交給 verifier,少了它的 token 在 java-jwt 那一層就被擋掉,不會走到我們自己的 validate,少寫一個 null 判斷,錯誤訊息也更精確,這件事在 log 那一節看得到

Clock 是建構子的第 2 個參數,不是 Instant.now(),時間從外面給進來,簽發時間就變成測試控制得了的東西

validate 回自己的型別,下游就不用動

Ktor 文件的 jwt 範例在 validate 裡回的是 JWTPrincipal,照抄的話,day 26 那條 /me

val user = call.principal<TodoUser>()

會拿到 null,因為 call 上放的是 JWTPrincipal 不是 TodoUser,接著 meRoute() 要改、/me 回的 JSON 形狀要改、測試要改,一路往下

但上面那份簽章已經說了,validate 的回傳型別是 Any?,既然是 Any?,回什麼都可以,那就回 day 26 那個原封不動的 TodoUser,整個 provider 的設定寫成一個 AuthenticationConfig 的擴充函式,位置一樣在 src/main/kotlin/com/cashwu/todo/Auth.kt

先把 day 26 那個 todoAuth 整段刪掉,就是這 6 行

fun AuthenticationConfig.todoAuth(realm: String, authenticator: TokenAuthenticator) {
    bearer(TODO_AUTH) {
        this.realm = realm
        authenticate { credential -> authenticator.authenticate(credential.token) }
    }
}

它收的 TokenAuthenticator 下一節就會被 PasswordAuthenticator 取代,留著編譯不過,而且 2 個 todoAuth 的簽章不一樣,Kotlin 不會報重複定義,只會報參數型別找不到,錯誤訊息不會直接說「你有 2 個 todoAuth」

換上去的是這個,bearer 換成 jwt,參數從 realm 跟 TokenAuthenticator 換成 AuthConfig 跟 TokenIssuer

fun AuthenticationConfig.todoAuth(config: AuthConfig, issuer: TokenIssuer) {
    jwt(TODO_AUTH) {
        realm = config.realm
        verifier(issuer.verifier())
        validate { credential ->
            val type = credential.payload.getClaim(TOKEN_USE_CLAIM).asString()
            val name = credential.payload.subject
            if (type == ACCESS_TOKEN && name != null) TodoUser(name) else null
        }
        challenge { defaultScheme, realm ->
            val sent = call.request.authorization()
                ?.let(::parseAuthorizationHeader)
                ?.authScheme
                ?.equals(defaultScheme, ignoreCase = true) == true
            val parameters = if (sent) {
                mapOf(HttpAuthHeader.Parameters.Realm to realm, "error" to "invalid_token")
            } else {
                mapOf(HttpAuthHeader.Parameters.Realm to realm)
            }
            call.respond(
                UnauthorizedResponse(
                    HttpAuthHeader.Parameterized(defaultScheme, parameters, HeaderValueEncoding.QUOTED_ALWAYS)
                )
            )
        }
    }
}

底下那個 challenge { } 是 day 26 沒有的 hook,day 26 結尾掛著的 2 筆 RFC 債都在這裡還

第 1 筆是引號,day 26 量到的 WWW-Authenticate: Bearer realm=todo-api 不符合 RFC 9110 §11.5 的 a sender MUST only generate the quoted-string syntax,Ktor 的編碼策略是 HttpAuthHeader.Parameterized 的一個參數,預設值 QUOTED_WHEN_REQUIRED 就是「需要才加引號」,自己挑戰自己回,換成 QUOTED_ALWAYS 就好

第 2 筆是 error 參數,RFC 6750 §3 說「請求帶了 access token 卻認證失敗」SHOULD 帶 error,同一份的 §3.1 又說「請求根本沒帶認證資訊,或用了不支援的方式」SHOULD NOT 帶,一條 SHOULD、一條 SHOULD NOT 方向相反,2 種情況回一樣的東西不管回哪一種都會違反其中一條,所以那個 sent 要分的是「有沒有送 Bearer 憑證」而不是「有沒有任何 Authorization header」,Basic ... 不能被誤報成 invalid_token

兩者的實際輸出等下面起了 server 再看

validate 裡只做 2 件 verifier 做不到的事,第 1 件是確認這是一張 access token 而不是 refresh token,因為 2 種 ticket 的簽章一樣有效、issuer 跟 audience 也一樣,verifier 分不出來,第 2 件是把 sub 換成一個 TodoUser,換不出來就回 null,跟 day 26 同一個約定

於是 meRoute() 一個字都沒動

fun Route.meRoute() {
    get("/me") {
        val user = call.principal<TodoUser>()
            ?: throw ApiException(HttpStatusCode.Unauthorized, "這個請求沒有身分")
        call.respond(user)
    }
}

ApiException 是 day 15 定的那個帶 status code 的例外,這段程式碼在 day 26 就是這樣,這篇只是把它留在原地

/login 跟 /refresh

現在缺的是「怎麼拿到第 1 張 ticket」,比對密碼那個類別是 day 26 的 TokenAuthenticator 改名跟改欄位,位置一樣在 src/main/kotlin/com/cashwu/todo/Auth.kt

是取代不是新增,day 26 那個 TokenAuthenticator 整個刪掉,它讀的 AuthUser::token 已經不存在了,留著就是 Unresolved reference 'token'

class PasswordAuthenticator(users: List<AuthUser>) {
    init {
        require(users.map(AuthUser::name).distinct().size == users.size) {
            "Duplicate user names are not allowed"
        }
    }

    private val entries = users.map { it.name to it.password.toByteArray(Charsets.UTF_8) }

    fun authenticate(name: String, password: String): TodoUser? {
        val candidate = password.toByteArray(Charsets.UTF_8)
        var matched: TodoUser? = null
        for ((user, expected) in entries) {
            val passwordMatches = MessageDigest.isEqual(expected, candidate)
            if (user == name && passwordMatches) {
                matched = TodoUser(user)
            }
        }
        return matched
    }
}

day 26 那 3 個刻意的寫法原樣保留,只是鍵換了,建構時先拒絕重複,day 26 擋的是重複的 token,這裡身分的鍵是 name,所以擋的是重複的名字,理由一樣,同一個鍵對到 2 筆設定的時候,下面那個迴圈會靜默地讓最後一筆贏

MessageDigest.isEqual 不會因內容的第 1 個不同位元組就提早返回,比較要先做,再判斷使用者名稱,如果把 2 個條件寫成 user == name && MessageDigest.isEqual(...),未知使用者會因短路運算而完全跳過密碼比較,迴圈也要跑完整份名單,不能用 firstOrNull 提早離開,才不會把「命中的是第幾個使用者」反映在時間上

它遮不掉的東西也一樣沒變,第 1 個陣列是設定裡的預期密碼,所以執行時間仍受設定裡那筆密碼的長度影響,不是受送進來那筆的長度影響

2 條路由,同一個檔案

fun Route.tokenRoutes(authenticator: PasswordAuthenticator, issuer: TokenIssuer) {
    post("/login") {
        val request = call.receive<LoginRequest>()
        val user = authenticator.authenticate(request.name, request.password)
            ?: throw ApiException(HttpStatusCode.BadRequest, "帳號或密碼不正確")
        call.respond(issuer.tokensFor(user))
    }
    post("/refresh") {
        val request = call.receive<RefreshRequest>()
        val payload = runCatching { issuer.verifier().verify(request.refreshToken) }.getOrNull()
            ?: throw ApiException(HttpStatusCode.BadRequest, "refresh token 不能用了")
        if (payload.getClaim(TOKEN_USE_CLAIM).asString() != REFRESH_TOKEN) {
            throw ApiException(HttpStatusCode.BadRequest, "這不是一個 refresh token")
        }
        call.respond(issuer.tokensFor(TodoUser(payload.subject)))
    }
}

進出的形狀是 3 個 @Serializable 的 data class,也在同一個檔案

@Serializable
data class LoginRequest(val name: String, val password: String)

@Serializable
data class RefreshRequest(val refreshToken: String)

@Serializable
data class TokenResponse(
    val accessToken: String,
    val refreshToken: String,
    val expiresIn: Long,
)

/refresh 那條路是自己拿 verifier 驗,不是走 jwt provider,原因是 provider 的 validate 只放 access token 過,refresh token 一定被擋掉,所以這條路自己驗完之後再檢查一次 token_use,確認來的是 refresh 而不是 access

最後是接線,src/main/kotlin/com/cashwu/todo/Application.kt 的 dependencies { } 跟下面的委派屬性

     dependencies {
         // ...
-        provide<TokenAuthenticator> { TokenAuthenticator(resolve<TodoConfig>().auth.users) }
+        provide<PasswordAuthenticator> { PasswordAuthenticator(resolve<TodoConfig>().auth.users) }
+        provide<TokenIssuer> { TokenIssuer(resolve<TodoConfig>().auth.jwt, resolve()) }
     }

     val config: TodoConfig by dependencies
     val repository: TodoRepository by dependencies
-    val authenticator: TokenAuthenticator by dependencies
+    val authenticator: PasswordAuthenticator by dependencies
+    val issuer: TokenIssuer by dependencies

TokenIssuer 的第 2 個參數寫的是 resolve(),拿的是 day 18 就註冊在容器裡的那個 Clock,這一行是後面測試那節的關鍵

然後是 install 跟 routing

     install(Authentication) {
-        todoAuth(config.auth.realm, authenticator)
+        todoAuth(config.auth, issuer)
     }
     routing {
         get("/") {
             call.respondText("Hello, Ktor!")
         }
+        tokenRoutes(authenticator, issuer)
         authenticate(TODO_AUTH) {
             meRoute()
             todoRoutes(repository)
         }
    }

tokenRoutes 放在 authenticate 外面,這不是風格問題,是死結問題,/login 如果在 authenticate 底下,要拿 token 得先有 token

而 authenticate(TODO_AUTH) 那一行在 diff 裡是 context line,前面沒有 + 也沒有 -

起 server 打打看,資料庫還是 day 23 那個 docker compose 起的 PostgreSQL,跟 day 26 同一條指令,前面換掉的是那 3 個沒有預設值的憑證

docker compose up -d
JWT_SECRET=local-dev-secret-32-bytes-minimum ALICE_PASSWORD=alice-secret BOB_PASSWORD=bob-secret \
DB_PASSWORD=todo ./gradlew run

JWT_SECRET 那串取成 local-dev-secret-32-bytes-minimum 不是隨手打的,那個長度是門檻,理由在後面「HMAC secret 的長度,java-jwt 不管」那一節

拿 alice 的帳密打 /login

curl -i -s -X POST localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","password":"alice-secret"}'
HTTP/1.1 200 OK
X-Request-Id: 68qyd+6cbzn+
X-Response-Time: 22ms
Content-Length: 512
Content-Type: application/json

{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b2RvLWFwaSIsImF1ZCI6InRvZG8tYXBpLWNsaWVudCIsInN1YiI6ImFsaWNlIiwidG9rZW5fdXNlIjoiYWNjZXNzIiwiaWF0IjoxNzg4NDkyMjg5LCJleHAiOjE3ODg0OTMxODl9.qNm3ns_6Ns614XbBRruC_dAdvV6nY9-m2cIwep966nY","refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b2RvLWFwaSIsImF1ZCI6InRvZG8tYXBpLWNsaWVudCIsInN1YiI6ImFsaWNlIiwidG9rZW5fdXNlIjoicmVmcmVzaCIsImlhdCI6MTc4ODQ5MjI4OSwiZXhwIjoxNzg5MDk3MDg5fQ.Rwroz28z1w02oFG6ksI_I6FZMQadzo-oSMpipwBvyag","expiresIn":900}

上一節解開的那 2 段 JSON 就是這一串的前 2 段,2 個 token 前面 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 一模一樣,因為 header 一樣,中段開始才分岔

下面幾個請求都要帶 token,所以登入一次把 body 存下來,2 串各取出來放進 shell 變數,接下來的輸出都是這一組打出來的

curl -s -X POST localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","password":"alice-secret"}' > /tmp/login.json
ACCESS=$(python3 -c 'import json; print(json.load(open("/tmp/login.json"))["accessToken"])')
REFRESH=$(python3 -c 'import json; print(json.load(open("/tmp/login.json"))["refreshToken"])')

拿 access token 打 /me

curl -i -s -H "Authorization: Bearer $ACCESS" localhost:8080/me
HTTP/1.1 200 OK
X-Request-Id: c5gjaykfvzh/
X-Response-Time: 13ms
Content-Length: 16
Content-Type: application/json

{"name":"alice"}

跟 day 26 一模一樣的回應,中間換掉的是整個認證機制,/me 這一端什麼都不知道

拿 refresh token 打 /refresh

curl -i -s -X POST localhost:8080/refresh \
  -H 'Content-Type: application/json' \
  -d "{\"refreshToken\":\"$REFRESH\"}"
HTTP/1.1 200 OK
X-Request-Id: xwr42onr3yp2
X-Response-Time: 1ms
Content-Length: 512
Content-Type: application/json

body 的形狀跟 /login 一樣,3 個欄位、512 個 byte,這裡有一件容易誤會的事,2 串 token 跟登入時拿到的逐字相同,不是它把舊的還回來,是新簽的一組剛好長一樣,sub、token_use 都沒變,iat 跟 exp 又落在同一秒,同樣的輸入 HS256 就給同樣的簽章,隔一秒再打一次 iat 進位,兩串就不一樣了

為什麼 /login 失敗是 400 不是 401

這個決定要解釋清楚,因為兩份 RFC 在這裡指向不同的方向

直覺會寫 401,帳密不對本來就是「沒有通過認證」,但那個 401 會少一個 header,它是 day 15 的 ApiException 從 exception<ApiException> 出去的,不是認證那條路上的挑戰,所以沒有人幫它加 WWW-Authenticate

而 RFC 9110 15.5.2 對 401 的規定逐字是

   The 401 (Unauthorized) status code indicates that the request has not
   been applied because it lacks valid authentication credentials for
   the target resource.  The server generating a 401 response MUST send
   a WWW-Authenticate header field (Section 11.6.1) containing at least
   one challenge applicable to the target resource.

MUST send a WWW-Authenticate header field,這個 401 違反了它

補一個上去也不對,補 WWW-Authenticate: Bearer 等於告訴 client「帶一個 bearer token 再來」,但它正是來拿 token 的,這個 challenge 語意上是死循環

另一條路有現成的規範可以抄,RFC 6749 5.2 講 token endpoint 的錯誤回應,逐字是

   The authorization server responds with an HTTP 400 (Bad Request)
   status code (unless specified otherwise) and includes the following
   parameters with the response:

同一節對 invalid_grant 的定義

  invalid_grant
        The provided authorization grant (e.g., authorization
        code, resource owner credentials) or refresh token is
        invalid, expired, revoked, does not match the redirection
        URI used in the authorization request, or was issued to
        another client.

resource owner credentials 就是帳號密碼,refresh token is invalid, expired, revoked 就是 /refresh 那 2 種失敗,我們這 2 條路的失敗全部落在這一格,而這一格是 400

那 401 呢,同一節裡唯一會回 401 的是 invalid_client

  invalid_client
        Client authentication failed (e.g., unknown client, no
        client authentication included, or unsupported
        authentication method).  The authorization server MAY
        return an HTTP 401 (Unauthorized) status code to indicate
        which HTTP authentication schemes are supported.  If the
        client attempted to authenticate via the "Authorization"
        request header field, the authorization server MUST
        respond with an HTTP 401 (Unauthorized) status code and
        include the "WWW-Authenticate" response header field
        matching the authentication scheme used by the client.

那個 MUST 有前提,If the client attempted to authenticate via the "Authorization" request header field,我們的 /login 是把帳密放在 JSON body 裡,前提不成立

所以這篇把 /login 跟 /refresh 的失敗改成 400,拿 access token 去打 /refresh

curl -i -s -X POST localhost:8080/refresh \
  -H 'Content-Type: application/json' \
  -d "{\"refreshToken\":\"$ACCESS\"}"
HTTP/1.1 400 Bad Request
X-Request-Id: -=0fzqm6ff7q
X-Response-Time: 3ms
Content-Length: 69
Content-Type: application/json

{"status":400,"message":"這不是一個 refresh token","details":[]}

亂寫一個字串進 /refresh 也是 400,只是訊息換成 refresh token 不能用了,那是驗不過而不是型別不對

密碼打錯

curl -i -s -X POST localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"name":"alice","password":"nope"}'
HTTP/1.1 400 Bad Request
X-Request-Id: ct78=bdxhzms
X-Response-Time: 2ms
Content-Length: 64
Content-Type: application/json

{"status":400,"message":"帳號或密碼不正確","details":[]}

沒有這個使用者也是 400,body 跟密碼打錯完全一樣,不告訴外面「這個帳號存不存在」,這件事會有測試擔保,後面會看到

curl -i -s -X POST localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"name":"carol","password":"whatever"}'
HTTP/1.1 400 Bad Request
X-Request-Id: jmuacgoy/w=2
X-Response-Time: 1ms
Content-Length: 64
Content-Type: application/json

{"status":400,"message":"帳號或密碼不正確","details":[]}

todo-api 不是 OAuth 2.0 的授權伺服器,RFC 6749 對它沒有強制力,上面是拿它當先例而不是拿它當規定,而且「密碼錯回 400」讀起來確實怪,400 在大部分人的直覺裡是「你的請求格式有問題」,帳密錯的請求格式明明沒問題

這個決定是可以吵的,換成 401 加一個非 Bearer 的挑戰也是一種答案,這篇選 400 的理由只有一個,401 沒有挑戰是明確違反 RFC 9110 的 MUST,兩害相權取其輕

兩筆 RFC 債的實際輸出

challenge 那一段還掉的 2 筆債,跑出來是這樣,沒帶 token 打 /todos

curl -i -s localhost:8080/todos
HTTP/1.1 401 Unauthorized
X-Request-Id: bmfxmtdi2-fw
X-Response-Time: 1ms
WWW-Authenticate: Bearer realm="todo-api"
Content-Length: 58
Content-Type: application/json

{"status":401,"message":"請帶著有效的 token 再來"}

跟 day 26 的差別只有 realm 那對引號,body 還是 day 26 那個形狀,ErrorHandling.kt 裡那段 status(HttpStatusCode.Unauthorized) 原封不動,58 個 byte 也一樣,而且照 3.1 這個回應不帶 error

拿 refresh token 去打 /todos,這個帶了憑證只是不合格,就多一個 error

curl -i -s -H "Authorization: Bearer $REFRESH" localhost:8080/todos
HTTP/1.1 401 Unauthorized
X-Request-Id: c=jvs7valt70
X-Response-Time: 0ms
WWW-Authenticate: Bearer realm="todo-api", error="invalid_token"
Content-Length: 58
Content-Type: application/json

{"status":401,"message":"請帶著有效的 token 再來"}

被拒的 token 在 log 裡分得出來

被拒的 token 不管是簽章不對、iss 不對、aud 不對、過期,還是少了 token_use,對外回的都是上面那個一模一樣的 401 加 error="invalid_token",這是刻意的,不要告訴攻擊者他哪一步錯了,「簽章對了但 audience 不對」這種回應等於在幫人做逐步逼近

代價是自己 debug 的時候也一樣看不出來,io.ktor.auth.jwt 這個 logger 預設不會出現在 INFO 的輸出裡,src/main/resources/logback.xml 加一行把它開到 DEBUG

     <logger name="io.netty" level="INFO"/>
+    <logger name="io.ktor.auth.jwt" level="DEBUG"/>

還有一件實際的事,每一個 verification 失敗除了那一行訊息之外還會印一整串 stack trace,40 幾行,所以起 server 的時候把輸出導到 server.log,再撈那個 logger 的名字出來看,上面那 2 個 401 長這樣

grep 'io.ktor.auth.jwt' server.log
11:24:49.489 DEBUG [bmfxmtdi2-fw] io.ktor.auth.jwt -- JWT authentication failed: No credentials provided
11:24:49.510 DEBUG [c=jvs7valt70] io.ktor.auth.jwt -- JWT validation failed: Custom validation returned null
11:24:49.510 DEBUG [c=jvs7valt70] io.ktor.auth.jwt -- JWT authentication failed: Invalid credentials

沒帶 token 的是 No credentials provided,帶 refresh token 的是 validation failed,另外還有第 3 種 verification failed,那是 java-jwt 的 verifier 擋的,簽章、iss、aud、過期、少 token_use 全歸在這一種,訊息會直接寫是哪一項,例如少了 claim 那種是 JWT verification failed: The Claim 'token_use' is not present in the JWT.

verification 跟 validation 一字之差就告訴你要去哪個檔案找問題,也回頭說明了 withClaimPresence 那一行的價值,少了 token_use 被歸在 verification 而不是 validation

另外這些訊息裡沒有 token 本身,開 DEBUG 不會把憑證寫進 log,而每一行都帶 day 16 的 call id,使用者回報「我一直被擋」的時候,拿他的 request id 就查得到是哪一種被擋

HMAC secret 的長度,java-jwt 不管

HS256 的 secret 就是一個字串,設定檔裡填什麼它就用什麼,那填多短會有事嗎,spike 一下

val token = JWT.create().withIssuer("todo-api").sign(Algorithm.HMAC256("short"))
val verified = JWT.require(Algorithm.HMAC256("short")).withIssuer("todo-api").build().verify(token)

印出來的是

SPIKE-SHORT: signed and verified, secret is 5 bytes, alg=HS256

5 個字元,簽得起來也驗得過,1 個警告都沒有,函式庫這一層不會擋你

但 RFC 7518 3.2 對 HS256 的規定逐字是

   A key of the same size as the hash output (for instance, 256 bits for
   "HS256") or larger MUST be used with this algorithm.  (This
   requirement is based on Section 5.3.4 (Security Effect of the HMAC
   Key) of NIST SP 800-117 [NIST.800-107], which states that the
   effective security strength is the minimum of the security strength
   of the key and two times the size of the internal hash value.)

256 bits 就是 32 個 byte,"short" 那 5 個 byte 差得很遠,RFC 括號裡那句 effective security strength is the minimum of the security strength of the key 講的就是這件事,整個簽章的強度被短的那一邊決定,而拿到一張 token 的人可以在自己機器上離線慢慢試,試出 secret 就能簽出任何身分的 ticket

這也是本機那個 JWT_SECRET=local-dev-secret-32-bytes-minimum 長成這樣的原因,它是 33 個 byte,剛好過線,取這個名字是為了讓下一個來起 server 的人看到門檻,application.yaml 裡它沒有預設值,正式環境給的要是隨機產生的,不會出現在版控裡的東西

調整有問題的測試

把 TestApp.kt day 26 的 TEST_TOKEN 移除,加上新的測試資料,一樣是從設定檔讀出來的字串

-val TEST_TOKEN: String = todoTestConfig.auth.users.first().token
+val testIssuer: TokenIssuer = TokenIssuer(todoTestConfig.auth.jwt, Clock.systemUTC())
+
+val TEST_USER: TodoUser = TodoUser(todoTestConfig.auth.users.first().name)
+
+val TEST_TOKEN: String = testIssuer.accessToken(TEST_USER)

todoTestConfig 是 day 17 放進 TestApp.kt 的那份直接讀 application.yaml 的設定,換完之後 TEST_TOKEN 還是一個 String,所以 day 26 那個 bearerClient(token: String) 不用改,那些把 TEST_TOKEN 塞進字串樣板的測試也不用改

ConfigurationTest 那個把整份設定比對一次的測試,auth 那一塊要跟著改,這是設定多一個必填欄位的必然結果,前面 requestIdHeader 到 DatabaseConfig 那幾層是 day 17 跟 day 23 留下的,一個字都沒動

         auth = AuthConfig(
             realm = "todo-api",
+            jwt = JwtConfig(
+                secret = "test-secret-32-bytes-minimum-okay",
+                issuer = "todo-api",
+                audience = "todo-api-client",
+                accessTtlSeconds = 900,
+                refreshTtlSeconds = 604_800,
+            ),
             users = listOf(
-                AuthUser("alice", "token-alice"),
-                AuthUser("bob", "token-bob"),
+                AuthUser("alice", "test-alice-secret"),
+                AuthUser("bob", "test-bob-secret"),
             ),
         ),

3 個憑證欄位的期望值就是 tasks.test 裡新加的那 3 行,這個測試因此還多驗一件事,測試跑的時候拿到的是測試憑證,不是別人機器上的環境變數

AuthenticationTest 有問題的測試要調整一下

第 1 處是那個自己拼 module 的 helper,簽名換了

private fun ApplicationTestBuilder.whoamiApplication(
    realm: String = "todo-api",
    optional: Boolean = false,
) {
    // ...
    application {
        install(Authentication) {
-           todoAuth(realm, TokenAuthenticator(todoTestConfig.auth.users))
+           todoAuth(todoTestConfig.auth.copy(realm = realm), testIssuer)
        }
        // ...
    }
}

第 2 處跟第 3 處是還債的直接後果,斷言裡多了引號跟 error 參數

@Test
fun `a request without a token is answered with a challenge`() = testApplication {
    todoApplication()

    val response = client.get("/todos")

    assertEquals(HttpStatusCode.Unauthorized, response.status)
-   assertEquals("Bearer realm=todo-api", response.headers[HttpHeaders.WWWAuthenticate])
+   assertEquals("""Bearer realm="todo-api"""", response.headers[HttpHeaders.WWWAuthenticate])
}
-    fun `an unknown token is rejected the same way as no token`() = testApplication {
+    fun `a token that was sent but rejected carries the error parameter`() = testApplication {
         todoApplication()

         val response = bearerClient("nope").get("/todos")

         assertEquals(HttpStatusCode.Unauthorized, response.status)
-        assertEquals("Bearer realm=todo-api", response.headers[HttpHeaders.WWWAuthenticate])
+        assertEquals(
+            """Bearer realm="todo-api", error="invalid_token"""",
+            response.headers[HttpHeaders.WWWAuthenticate],
+        )
     }

這個測試連名字都反過來了,day 26 它叫「不認識的 token 跟沒 token 走同一條路」,現在叫「送了 token 但被拒會帶 error 參數」,同一個測試,同一個請求,斷言的東西剛好是相反的結論

第 4 處是 2 個 token 換 2 個使用者那個測試,day 26 是把設定檔裡的字串寫進測試,現在得先簽

@Test
fun `each token resolves to its own user`() = testApplication {
    todoApplication()

-   assertEquals("""{"name":"alice"}""", bearerClient("token-alice").get("/me").bodyAsText())
-   assertEquals("""{"name":"bob"}""", bearerClient("token-bob").get("/me").bodyAsText())
+   val alice = testIssuer.accessToken(TodoUser("alice"))
+   val bob = testIssuer.accessToken(TodoUser("bob"))
+
+   assertEquals("""{"name":"alice"}""", bearerClient(alice).get("/me").bodyAsText())
+   assertEquals("""{"name":"bob"}""", bearerClient(bob).get("/me").bodyAsText())
}

還有 realm 那個測試從 1 個變成 2 個,day 26 那個測的是「realm 裡有空白,所以會被加引號」,那是 Ktor 預設 QUOTED_WHEN_REQUIRED 的行為,換成 QUOTED_ALWAYS 之後真正要驗的是「不需要引號的時候也加」,所以補一個沒有空白的 realm 進去,day 26 那個留著,只是名字改成不再宣稱「需要」

@Test
fun `the realm is quoted even when it does not have to be`() = testApplication {
    whoamiApplication(realm = "todo-api")

    val response = client.get("/whoami")

    assertEquals("""Bearer realm="todo-api"""", response.headers[HttpHeaders.WWWAuthenticate])
}

// 原本的名稱是 `a realm that needs quoting gets quoted`,只是改個名字而已
@Test
fun `a realm with a space is quoted too`() = testApplication {
    whoamiApplication(realm = "todo api")

    val response = client.get("/whoami")

    assertEquals("""Bearer realm="todo api"""", response.headers[HttpHeaders.WWWAuthenticate])
}

這篇加的測試

開一個新檔案 src/test/kotlin/com/cashwu/todo/JwtTest.kt

2 個 helper,jwtConfig 讓每個測試都從同一份設定拿期望值,issuerWith 讓需要換設定或換時鐘的測試各自簽自己的 token

private val jwtConfig: JwtConfig = todoTestConfig.auth.jwt

private fun issuerWith(
    config: JwtConfig = jwtConfig,
    clock: Clock = Clock.systemUTC(),
): TokenIssuer = TokenIssuer(config, clock)

還有一個 login,2 個參數都有預設值,所以正向的測試寫 login()、要打錯的時候寫 login(password = "nope")

private suspend fun ApplicationTestBuilder.login(
    name: String = "alice",
    password: String = "test-alice-secret",
) = client.post("/login") {
    contentType(ContentType.Application.Json)
    setBody("""{"name":"$name","password":"$password"}""")
}

第 1 組是 token 本身長什麼樣,3 個測試

/login 回 3 個欄位、access token 帶著 verifier 要的每一個 claim、refresh token 只在 2 個 claim 上跟 access token 不一樣

@Test
fun `the access token carries the claims the verifier asks for`() = testApplication {
    todoApplication()

    val tokens = Json.decodeFromString<TokenResponse>(login().bodyAsText())
    val decoded = JWT.decode(tokens.accessToken)

    assertEquals("HS256", decoded.algorithm)
    assertEquals(jwtConfig.issuer, decoded.issuer)
    assertEquals(listOf(jwtConfig.audience), decoded.audience)
    assertEquals("alice", decoded.subject)
    assertEquals(ACCESS_TOKEN, decoded.getClaim(TOKEN_USE_CLAIM).asString())
    assertEquals(
        jwtConfig.accessTtlSeconds,
        decoded.expiresAtAsInstant.epochSecond - decoded.issuedAtAsInstant.epochSecond,
    )
}

JWT.decode 只解不驗,這裡要的就是「看看裡面到底放了什麼」,最後那個減法斷言的是 TTL,跟前面 curl 出來那個 1788493189 - 1788492289 = 900 是同一件事,只是這次每跑一次都會檢查

第 2 組是登入失敗,2 個測試,密碼錯是 400 而且沒有 challenge

@Test
fun `the wrong password is a 400 without a challenge`() = testApplication {
    todoApplication()

    val response = login(password = "nope")

    assertEquals(HttpStatusCode.BadRequest, response.status)
    assertNull(response.headers[HttpHeaders.WWWAuthenticate])
    assertContains(response.bodyAsText(), "帳號或密碼不正確")
}

assertNull 那一行比 status 那一行重要,它驗證的是「這個回應不是一個認證 challenge」,哪天有人把 /login 移進 authenticate 裡面,這個斷言會先失敗

另一個是不存在的帳號跟密碼打錯回一模一樣的東西

@Test
fun `an unknown name gets the same answer as a wrong password`() = testApplication {
    todoApplication()

    val unknown = login(name = "mallory")
    val wrong = login(password = "nope")

    assertEquals(HttpStatusCode.BadRequest, unknown.status)
    assertEquals(wrong.bodyAsText(), unknown.bodyAsText())
}

它沒有斷言任何一個字串常數,斷言的是 2 個回應相等,這樣寫的話,之後有人改了其中一邊的訊息,這個測試會失敗,而它要驗證的正是「這兩邊不能不一樣」

第 3 組是各種假 ticket 被擋

第 1 個是過期,access token 活 900 秒,測「過期會被拒」最笨的做法是 Thread.sleep(900_000),不用,TokenIssuer 的第 2 個參數是 Clock,給它一個往回撥的 Clock.fixed,簽出來的 token 一出生就已經過期

@Test
fun `an expired token is rejected`() = testApplication {
    todoApplication()
    val longAgo = Clock.fixed(Instant.now().minusSeconds(jwtConfig.accessTtlSeconds * 2), ZoneOffset.UTC)
    val stale = issuerWith(clock = longAgo).accessToken(TEST_USER)

    val response = bearerClient(stale).get("/todos")

    assertEquals(HttpStatusCode.Unauthorized, response.status)
    assertEquals(
        """Bearer realm="todo-api", error="invalid_token"""",
        response.headers[HttpHeaders.WWWAuthenticate],
    )
}

從現在往回撥兩倍的 TTL,簽的時候 exp 就落在過去,server 那邊的 verifier 拿現在的時間一比就過期了,把 jwt 的 logger 開到 DEBUG 跑這個測試,看到的是這一行

20:04:57.916 DEBUG [6v74+j3=-zu=] io.ktor.auth.jwt -- JWT verification failed: The Token has expired on 2026-08-31T11:49:57Z.

第 1 版寫的不是 Instant.now() 而是 TestApp.kt 裡那個 FIXED_NOW,也就是 2026-10-10T12:00:00Z,測試一樣通過,但通過的理由完全不是過期

20:05:11.720 DEBUG [qvhoheezz71w] io.ktor.auth.jwt -- JWT verification failed: The Token can't be used before 2026-10-10T11:30:00Z.

寫這篇的當下 FIXED_NOW 還在未來,往回撥 1800 秒之後仍然是未來,exp 也還是未來,token 根本沒過期,擋下它的是 java-jwt 對 iat 的檢查,那個 claim 落在未來,於是「還不能用」

一個測「過期」的測試靠「簽發時間在未來」通過,名字跟行為對不上,真正想驗證的那件事根本沒被驗證到,只看它通過就收工的話這個洞會一直留著,是那行 DEBUG log 把它翻出來的,day 18 定義 fixedClock(instant) 的時候刻意不給預設值、要求呼叫端把時間寫出來,防的就是這種用法,只是那時候還沒有一個測試會因此出事

補一句,上面那 2 行是寫這篇當下量到的,重跑的時候 2026-10-10 可能已經是過去,測試會因為真的過期而通過,這個坑就重現不出來了

這一招能用是因為 day 18 那篇把 Clock 放進了 DI 容器,而這篇接線的時候寫的是 TokenIssuer(resolve<TodoConfig>().auth.jwt, resolve()),第 2 個 resolve() 拿的就是那個 Clock,當時那篇做的事是為了讓 created_at 可測,這篇拿到的是「時間相關的認證行為可測」

同樣的套路換個方向,換一把 secret、換一個 issuer、換一個 audience,因為 JwtConfig 是 data class,一行 copy 就是一個惡意的簽發者

@Test
fun `a token signed with another secret is rejected`() = testApplication {
    todoApplication()
    val forged = issuerWith(jwtConfig.copy(secret = "another-secret-that-is-not-ours")).accessToken(TEST_USER)

    assertEquals(HttpStatusCode.Unauthorized, bearerClient(forged).get("/todos").status)
}

設定物件宣告成 data class 換來的東西,在這裡才看得完整

第 5 個是簽章完全正確但少了 token_use

@Test
fun `a signed token without the token use claim is rejected`() = testApplication {
    todoApplication()
    val incomplete = JWT.create()
        .withIssuer(jwtConfig.issuer)
        .withAudience(jwtConfig.audience)
        .withSubject("alice")
        .withIssuedAt(FIXED_NOW)
        .withExpiresAt(FIXED_NOW.plusSeconds(jwtConfig.accessTtlSeconds))
        .sign(Algorithm.HMAC256(jwtConfig.secret))

    assertEquals(HttpStatusCode.Unauthorized, bearerClient(incomplete).get("/todos").status)
}

第 4 組是 2 種 token 不能互換,refresh token 打不進 /todos、access token 進不了 /refresh、驗不過的字串在 /refresh 是 400、正常的 refresh 換到一組新的而且真的能用

@Test
fun `refresh answers a new pair that works`() = testApplication {
    todoApplication()
    val first = Json.decodeFromString<TokenResponse>(login().bodyAsText())

    val response = client.post("/refresh") {
        contentType(ContentType.Application.Json)
        setBody("""{"refreshToken":"${first.refreshToken}"}""")
    }

    assertEquals(HttpStatusCode.OK, response.status)
    val second = Json.decodeFromString<TokenResponse>(response.bodyAsText())
    assertEquals(HttpStatusCode.OK, bearerClient(second.accessToken).get("/todos").status)
    assertEquals("""{"name":"alice"}""", bearerClient(second.accessToken).get("/me").bodyAsText())
}

最後拿新的 access token 去打 2 條受保護的路由,因為「回了 200 並且 body 裡有東西」不等於「換到的 ticket 能用」

第 5 組只有一個測試,盯的是那條死結

@Test
fun `the token endpoints are outside the authenticate block`() = testApplication {
    todoApplication()

    assertEquals(HttpStatusCode.OK, login().status)
    assertTrue(client.get("/todos").status == HttpStatusCode.Unauthorized)
}

2 個斷言要一起看才有意義,上面那行說 /login 不用 token 就打得到,下面那行說同一個 client 打 /todos 是 401,也就是這個 client 確實沒帶身分,少了第 2 行,第 1 行有可能是因為認證整個沒裝上去而通過

跟 Relix 的對照

Relix 是「Kotlin 手刻 Ktor 從零開始」那個系列自己寫的框架,它的 day 23 一次做完認證跟授權,但沒有做 JWT,而且那個系列在 day 23 跟 day 30 一共說了 4 次「真實應用會驗 JWT」,這篇就是去把那 4 句話兌現,這一節提到的 day 幾都是 Relix 那個系列的,不是這個系列的

Relix day 23 寫的是「bearer { } 裡的 token 是寫死的,正式環境這裡會去查資料庫或驗 JWT 簽章,但 middleware 那一層不用知道差別,它只要拿到 Principal 或 null」,整個認證機制從查名單換成驗簽章,authenticate(TODO_AUTH) 那一行沒動、meRoute() 沒動、day 26 那 60 行 val client = 沒動,只有 3 個測試檔案要改,當時那是推論,現在是有 numstat 的事實

但它成立不是因為框架很厲害,是因為兩邊的驗證函式都回「自己的型別或 null」,照 Ktor 文件的範例在 validate 裡回 JWTPrincipal 的話,meRoute() 的 call.principal<TodoUser>() 就會拿到 null,之後每一個要拿身分的地方都得改成 principal<JWTPrincipal>() 再去挖 payload,同一句話、同一個框架,換個寫法就不成立,這是一個要自己維持的性質

測試 helper 那一點結論一樣、路徑不太一樣,Relix day 30 說「測試 helper 可以繼續用寫死的 token」,這篇的 TEST_TOKEN 沒有繼續寫死,換成 testIssuer.accessToken(TEST_USER) 每次跑測試都現簽一張,但型別還是 String,呼叫端不用跟著改,可預測、可重複靠的不是「寫死」,是「同一份設定簽出來的 token 一定驗得過」

最後是 Relix 的 day 31 那張功能對照表,Authentication 那一列右邊是 Bearer/JWT/OAuth/Session/多策略,這篇把 JWT 那個詞填起來,表格上是一個詞,實際上是一整串相依,其中還有一個這篇沒用到的 jwks-rsa,功能對照表天生會低估右邊那一欄的成本,看那種表的時候要自己補上這一筆


小結

bearer 換成 jwt,verifier 那一層做規格上的機械檢查,簽章、iss、aud、過期,validate 才做我們自己的判斷,它的回傳型別跟 day 26 的 bearer 一樣是 Any?,所以回的是自己的 TodoUser 而不是 JWTPrincipal,meRoute() 一個字都沒動,換掉整個認證機制只動到 3 個既有測試檔案,但這不是框架給的保證,照 Ktor 文件的範例回 JWTPrincipal,下游每一個要拿身分的地方都得改

secret 跟 2 個 password 沿用 day 26 的 fail-fast,沒有版控預設值,少給就啟動失敗,測試憑證放在 tasks.test 的 environment,TokenIssuer 的第 2 個參數是 day 18 註冊在容器裡的 Clock,過期的測試因此不用等也不用 sleep,只是往回撥的基準要是 Instant.now() 不是 FIXED_NOW,不然通過的理由會變成 iat 落在未來

/login 跟 /refresh 放在 authenticate 外面,失敗回 400 不回 401,因為沒有挑戰的 401 違反 RFC 9110 15.5.2 的 MUST,而對一個來拿 token 的 client 送 Bearer 挑戰是死循環,day 26 那 2 筆 RFC 債靠 challenge 這個 bearer 沒有的鉤子還掉,被拒的 token 對外一模一樣,io.ktor.auth.jwt 開到 DEBUG 之後對內分得出來是誰擋的

留下的問題要先講清楚,才不會有人照抄上線,使用者名單還在 application.yaml 裡,密碼是明文,正式環境要進資料庫而且要雜湊,refresh token 是無狀態的,以目前的情況,簽出去就拿不回來,登出跟撤銷都做不到,HS256 是對稱金鑰,多個服務要驗同一批 token 的時候會換成 RS256 加 JWKS,那正是這篇裝進來卻完全沒用到的 jwks-rsa


下一篇

下一篇是授權,這篇的 alice 跟 bob 都拿得到 token,但拿到之後能做什麼 ? bob 能不能刪掉 alice 的待辦,TodoUser 裡連一個角色欄位都沒有,authenticate 也只回答「你是不是你說的那個人」,day 28 在處理授權,用自訂 plugin 做 route 層級的角色檢查,分清楚 401 跟 403 的邊界


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 26 Authentication 架構與 Bearer
下一篇
Kotlin Ktor 實戰 101 Day 28 Authorization 與角色
系列文
Kotlin Ktor 實戰 101 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言